Skip to content

sdk: give v1 byte fields the type the proto declares - #1124

Merged
kvinwang merged 5 commits into
nextfrom
feat/sdk-v1-bytes
Aug 25, 2026
Merged

sdk: give v1 byte fields the type the proto declares#1124
kvinwang merged 5 commits into
nextfrom
feat/sdk-v1-bytes

Conversation

@kvinwang

Copy link
Copy Markdown
Collaborator

Closes the last SDK item on the 0.6 prerelease checklist (#1094): "Represent protobuf byte fields as bytes in Rust, not hex strings."

Stacked on #1122#1120. Review those first; this diff is only the commit on top.

The state it found

An audit of the v1 surface's eleven bytes fields found no two SDKs drawing the line in the same place:

decoded bytes hex + decoder bare hex
Go 11 0 0
Rust 0 8 3
Python 0 5 6
JS 3 1 7

AttestResponse.attestation was the worst cell: Go returned []byte, Rust and Python each shipped a decode_attestation(), and JS gave you a string and nothing at all.

Why the type, not just the helper

docs/guest-api-v1.md has to say of the chain claim that public_key is "the raw derived public key ... not a hex string", and its verification steps say to rebuild the claim from raw bytes. Three separate documents have to repeat "hash the decoded bytes, not the JSON string as returned" for evidence.

Those sentences exist because the types did not say it. Pass a hex public_key straight into the claim builder and you build the claim over 66 ASCII characters instead of 33 bytes — no type error, no exception, just a chain that never verifies. sha256(bundle.evidence) on a hex string compiles and returns a confidently wrong digest. In Go both mistakes are unspellable, which is the property the other three now get.

What changed

All eleven fields become the language's own byte type — Vec<u8>, bytes, Uint8Array — including the InfoResponse identity fields, and every v1 decode_* helper is deleted. Rust's AttestConfig.report_data goes with them: it is a pub request type that took hex while attest() took bytes, the only place a caller using the builder directly had to encode by hand.

The wire does not change. JSON still carries lowercase hex; the encoding moves into the serialization layer — #[serde(with = "hex::serde")] in Rust with a small module for the repeated bytes chain, one Annotated pydantic alias in Python, and decoding at the client boundary in JS, which has no serialization layer to put it in.

v0 keeps its hex strings and its decoders, untouched. That surface mirrors the released 0.5.x SDK so a 0.5.x program keeps working by changing only a class name. Retyping every byte field there would break exactly the promise the v0 client exists to make.

Two things this surfaced

  • cargo test in sdk/rust never ran the types crate. The workspace root is itself a package, so the default member is dstack-sdk alone — the new unit tests would have been dead in CI. run-tests.sh now passes --workspace.
  • The JS wire types were derived from the public ones via Omit<GpuEvidenceBundleV1, 'decodeEvidence'>. Once that method is deleted the Omit silently becomes the decoded type, which would have typed send_rpc_request as returning a Uint8Array straight out of JSON. Both wire shapes are written out explicitly now.

Verification

./sdk/run-tests.sh exit 0 — Rust 51 tests plus both examples, Go clean on both packages, Python 160 passed, JS 141 passed. Plus the three no_std/wasm targets CI checks (wasm32-unknown-unknown, no_std_test --no-default-features, thumbv6m-none-eabi), clippy --workspace --all-targets -D warnings, gofmt/go vet, tsc --noEmit, and pdm run check.

One note on hex: its serde feature is the implicit feature of an optional dependency, so it does not appear in the manifest's [features]. Confirmed from the vendored source that hex::serde needs only core plus alloc, which this crate already has — and then confirmed empirically on all three bare-metal targets.

Copilot AI lite review requested due to automatic review settings August 25, 2026 00:51

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Base automatically changed from feat/sdk-v0-naming to next August 25, 2026 03:36
Where `agent_rpc_v1.proto` says `bytes`, three of the four SDKs handed the
caller a hex string. No two of them drew the line in the same place either:
Go decoded all eleven byte fields, JS decoded three, Rust and Python decoded
none and shipped a partial set of `decode_*` helpers -- Rust was missing three,
Python six.

The type is not cosmetic here. `docs/guest-api-v1.md` has to say of the chain
claim that `public_key` is "the raw derived public key ... not a hex string",
and three separate documents have to repeat "hash the decoded bytes, not the
string as returned" for `evidence`. Those sentences exist because the types
did not say it: pass a hex `public_key` straight into the claim builder and
you build the claim over 66 ASCII characters instead of 33 bytes -- no type
error, no exception, just a chain that never verifies. In Go that mistake is
unspellable.

All eleven fields are now the language's own byte type -- `Vec<u8>`, `bytes`,
`Uint8Array` -- including the `InfoResponse` identity fields, and every v1
`decode_*` helper is gone. Rust's `AttestConfig.report_data` goes with them:
it is a public request type that took hex while `attest()` took bytes, the
only place a caller had to encode by hand to use the builder directly.

The wire is unchanged. JSON still carries lowercase hex, moved into the
serialization layer: `hex::serde` in Rust with a small module for the
`repeated bytes` chain, one annotated pydantic alias in Python, and decoding
at the client boundary in JS, which had no serialization layer to put it in.

v0 keeps its hex strings and its decoders, untouched. That surface mirrors the
released 0.5.x SDK so a 0.5.x program keeps working by changing only a class
name; retyping every byte field would break exactly that promise.

Two things surfaced on the way. `cargo test` in `sdk/rust` never ran the types
crate -- the workspace root is itself a package, so the default member was
`dstack-sdk` alone and the new unit tests would have been dead in CI;
`run-tests.sh` now passes `--workspace`. And the JS wire types were derived
from the public ones via `Omit<..., 'decodeEvidence'>`, which silently becomes
the decoded type once that method is deleted, typing `send_rpc_request` as
returning `Uint8Array` from JSON. Both wire shapes are written out now.
Three defects an adversarial pass found in the conversion itself.

Rust's `report_data` kept `#[builder(into)]`, and both `&str` and
`String` implement `Into<Vec<u8>>`. So `.report_data("00ff")` still
compiled and attested the four ASCII bytes of that string -- exactly the
failure the change was made to eliminate, on the one field a caller
sends rather than receives. It is now a `ReportData` newtype that
converts from a `Vec<u8>`, an array or a slice and from nothing else, so
the builder keeps the coercion that made those three ergonomic while a
string becomes a type error. A `compile_fail` doctest keeps it one.

JavaScript decoded with `Buffer.from(value, 'hex')`, which stops at the
first pair it cannot parse and returns the prefix, silently: a corrupted
`app_id` came back as a short `Uint8Array`, `signature_chain:
["aabb","qq"]` came back one link short, and an absent required field
came back as an empty array that is *truthy*, so `if (!info.app_id)`
guards became dead code. Rust, Python and Go all reject these. It now
throws and names the field, and absence of a required field is an error
rather than empty bytes -- `os_image_hash` and `mr_aggregated` still
read as empty, so a degraded `Info` stays parseable.

Python accepted hex with embedded whitespace, which `bytes.fromhex`
skips, while raising a message that said it did not; and it rejected a
response that omits `os_image_hash` or `mr_aggregated`, where Rust has
`#[serde(default)]`. Both aligned.

None of this was covered: JS and Python had zero negative tests for any
v1 byte field, which is why a green suite proved nothing here. Added,
and mutation-checked -- four of the five new JS tests fail against the
old lenient decoder.

Also corrects the CHANGELOG, which said four of the eleven fields had no
Rust decoder where the number is three, and which claimed "the wire is
unchanged" without saying that only the JSON wire is: borsh writes a
`Vec<u8>` as length-prefixed bytes where it wrote a hex `String`, so a
0.5.x blob deserializes without error into the wrong content. Three
comments called `os_image_hash` and `mr_aggregated` `optional` on the
wire; the proto declares them plain `bytes`, and `not_before` and
`not_after` are the only `optional` fields it has. Tolerating their
absence is a client-side choice, and now says so.
Differential testing against the other three SDKs -- 194 identical JSON
bodies through four real clients -- found the JavaScript decoder still
producing a silently wrong value, in the helper added to stop exactly
that. `RegExp.test` stringifies its argument, so `app_id: ["00112233"]`
passes the hex check, and `Buffer.from` then ignores its `'hex'`
argument for a non-string input and coerces the element as an octet:
`Number('00112233') & 0xff`, one attacker-chosen byte, no error. Rust,
Python and Go all reject the same body. Decoding now requires a string
before it looks at the characters.

The same run found four more shapes where JavaScript was the outlier,
all of them absence or `null` rather than bad hex, so all reachable only
from a corrupted or non-dstack server -- a conforming agent emits every
field, always lowercase hex, never `null`:

- `signature_chain` was dereferenced bare, so `null` or an absent field
  raised `TypeError: Cannot read properties of null (reading 'map')`,
  which names no field and reads as an SDK bug rather than a bad
  response. Repeated fields now go through one checked accessor.
- `bundles ?? []` read an absent `bundles` as "this host has no GPUs",
  which is a different claim from "the response was malformed". Absence
  is an error there and empty for `boottime_gpu_evidence`, which is the
  distinction the proto draws.
- `os_image_hash: null` was read as empty bytes. Only an absent key is
  the empty default now, which is what `#[serde(default)]` means in Rust
  and what the pydantic default does in Python; an explicit `null` is a
  malformed value in all three.
- Responses were built by spreading the raw JSON, so a field the agent
  omitted came back `undefined` from an interface that declares it
  `string` -- `info().app_compose.length` threw on a value the compiler
  called safe. All four responses are now built field by field.

Rust gets `#[serde(default)]` on `InfoResponse::app_name`, the one
string in that struct without it, where absence was an error in Rust and
the empty string in the other three.

Also: `docs/confidential-ai.md` printed `info.compose_hash` for a data
provider to compare against the compose file they reviewed, which is a
bytes repr since the retyping; and the CHANGELOG's borsh warning pointed
at 0.5.x, which never shipped these types -- the window is between 0.6
prereleases.

Every new test was mutation-checked: each fails against the code it
fixes.
Rust and Go always took bytes here. Python and JavaScript also accepted a
string and UTF-8 encoded it, which reads as a convenience right up until
the string is a hex digest:

    client.attest("deadbeef")   # eight ASCII characters, not four bytes

No error, no exception, just a quote over a value the caller did not
mean. `attest_gpu` was worse: SPDM fixes the nonce at 32 bytes, so a
32-character string passed the length check on its way to attesting the
wrong nonce, and the check that was supposed to catch a bad nonce was
the reason this one got through.

This is the request-direction half of typing the v1 `bytes` fields. The
response half made a hex string unspellable where bytes were meant; the
same argument applies to the two fields a caller sends, and Rust's
`AttestConfig` already got it in the form of the `ReportData` newtype.
Both SDKs now raise, and name the two ways out -- encode the text or
decode the hex -- because only the caller knows which was meant.

Python routes both parameters through one `_require_bytes`, so the two
methods cannot drift: `attest_gpu` previously folded the type check into
its length check and answered a non-bytes nonce with a complaint about
its length.

**Breaking for a v1 caller passing a string.** v1 has not shipped, and
the v0 clients keep the old signature untouched -- a 0.5.x program
migrating by class name alone is unaffected, and that surface is frozen
precisely so this kind of change cannot reach it.

The JavaScript check is at runtime rather than in the parameter type
alone: a plain-JS caller, or TypeScript holding an `any` out of
`JSON.parse`, reaches the method with a string and no compiler in the
way.

Both new tests were mutation-checked against the lenient behaviour they
replace.
…t some

Differential testing -- 194 identical JSON bodies through four real
clients, one fake agent per body -- put the four SDKs at 143 agreements
and 51 divergences. Every divergence was absence, `null`, or JSON type
confusion; none was bad hex, and none is reachable from a conforming
agent, which emits every field, always lowercase hex, never `null`. All
of them are reachable from a compromised or non-dstack server, which is
the threat model private keys, signature chain links and application
identity already take seriously.

Go was the outlier in 39 of them, and its failure mode was the worst
available: decoding into a `string` turned both an absent key and a JSON
null into "", "" hex-decodes to empty bytes, and the error was nil. An
error body arriving with a 200 -- the one shape the transport's status
check does not cover -- therefore returned a zero-length private key,
an empty app_id, and `err == nil`. Required fields now decode through a
pointer so absence is visible, `rpcError` surfaces the agent's own words,
and the fields whose absence is genuinely the empty default read the raw
JSON, because a pointer cannot tell an omission from a null and only one
of those is an answer.

JavaScript's bundle decoding kept the same gap the response fields had:
`vendor` and `format` came through a spread of the raw JSON, so a bundle
missing one yielded `undefined` from an interface declaring `string`.
Those two are what a caller switches on to pick a verifier, so the
evidence was not degraded, it was routed to no verifier at all --
quietly, since `undefined` matches no `case`.

Rust and Python needed nothing here; they already refused every one of
these bodies. The remaining seven divergences after the Go rewrite were
Go accepting `null` for `Info`'s plain string fields, fixed the same way.

The 194 bodies now agree 194/194.
@kvinwang
kvinwang merged commit adeace4 into next Aug 25, 2026
17 checks passed
@kvinwang
kvinwang deleted the feat/sdk-v1-bytes branch August 25, 2026 04:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants